たるだめ

のんびりとなんか書きます

serverless Framework(Node.js、TypeScript)でLambda Layer

Serverless FrameworkでLambdaレイヤーを使うときにハマったメモ

概要

Serverless Framework(typescript)で Lambda Layers で自作モジュールの共通化を図ろうとしたところ、結構苦労してしまったので、メモとして残します。

検証に使ったコードは GitHub に置きました。

環境

今回検証した環境です。

  • serverless v3.32.2
  • serverless framework のテンプレート;aws-nodejs
  • Lambda Node.js 18.x

レイヤーのデプロイまで

layer 用の関数

何の変哲もないテストコードを用意しました。

npm install したモジュールを使うパターンと使わないパターンです。

layers/test-layer/lib/hoge.ts
export const hoge = (fuga = 'fuga') => 'hoge' + fuga;
layers/test-layer/lib/now.ts
import dayjs from 'dayjs';
export const now = () => dayjs();

レイヤーを追加してデプロイを試みる

serverless create などの準備は省略します。

layers:
  test-layer:
    path: layers/test-layer
    name: test-layer-${sls:stage}
    compatibleRuntimes:
      - nodejs18.x
    compatibleArchitectures:
      - x86_64

serverless framework の Lambda Layers のリファレンスを参考に書いてみましたが、上手くいきません。

Error:
No file matches include / exclude patterns

include、exclude のパスが誤っているようですが、serverless.yml には include も exclude も指定していません。

tsconfig.json の include、exclude も合っていると思いますが・・

serverless-plugin-typescript をダウングレードしてみる

ググっていたらこのような記事があったので、書いてある通りに serverless-plugin-typescript のバージョンを下げてみました。

# ^2.1.5 → ^2.1.1
npm i -D serverless-plugin-typescript@2.1.1

エラー内容が違うので、多分解決はしないだろうと思いましたが、案の定エラーのままでした。

Error:
No file matches include / exclude patterns

esbuild に変えてみる

色々見て、serverless 作成の時のテンプレートを「aws-nodejs-typescript」で作成したときには、「serverless-plugin-typescript」が含まれておらず、変わりに 「serverless-esbuild」 になっていることに気が付きました。

  "devDependencies": {
    "@serverless/typescript": "^3.0.0",
    "@types/aws-lambda": "^8.10.71",
    "@types/node": "^14.14.25",
    "esbuild": "^0.14.11",
    "json-schema-to-ts": "^1.5.0",
    "serverless": "^3.0.0",
    "serverless-esbuild": "^1.23.3",
    "ts-node": "^10.4.0",
    "tsconfig-paths": "^3.9.0",
    "typescript": "^4.1.3"
  },

前は、あったはずなんですが、、変わったんでしょうか。

とういわけで、esbuild をインストールします。

npm i -D serverless-esbuild esbuild

プラグインも変更します。

plugins:
  # - serverless-plugin-typescript
  - serverless-esbuild

こんなエラーが出てしまったので、、、

2023 06 24 13 01 55

yml を見直して修正しました。

package:
  individually: true

package は成功したが・・・

ようやく package まで完了しました。

2023 06 24 13 03 22

しかしまだ問題があります。

トランスパイルされていません。TS のままです。

2023 06 24 13 21 56

通常の関数は JS になっています。

2023 06 24 13 53 58

external に’*‘を指定してみましたが、またまた別のエラー。

2023 06 24 15 23 02

少し該当のコードを見てみましたが、プロジェクトルートの package.json を readFileSync で読み込んでいるようです。

しかし、エラーを見るとプロジェクトの 1 階層上を見ていました。

根が深そうなのでスルーします。

layer フォルダ、無視してるかも

serverless-esbuild のドキュメントには、以下のような記述がありました。

If you wish to use this plugin alongside non Node functions like Python or functions with images, this plugin will automatically ignore any function which does not contain a handler or use a supported Node.js runtime.

https://github.com/floydspace/serverless-esbuild/tree/master

翻訳すると、「このプラグインはハンドラを含まない関数やサポートされている Node.js ランタイムを使用しない関数を自動的に無視します。」とあります。

ハンドラって Lambda 関数のイベント処理コードのことだったはずなので、Lambda レイヤーはハンドラに含まれないから無視されている?

自力で esbuild

仕方がないので、layer の TS は自力で esbuild で JS にして packge することにしました。

layer 用のディレクトリはこのようになっています。

2023 06 24 16 12 11

lib 配下に自作モジュールを置き、nodejs 配下には layer の中で使用する package が含まれています。

公式リファレンスを見ると lib は PATH が通されているようなのでここに配置するようにしました。

というか node_modules の配下は Git 管理から ignore しちゃうのでよろしくない・・・

entryPoint にワイルドカードを指定するため、glob パッケージも追加しました。

npm i glob

build 用スクリプトを用意します。

build.js
const { build } = require("esbuild")
const glob = require("glob")

// node_modulesを含めない
const entryPoints = glob.sync('./test-layer/lib/**/*.ts')

build({
  entryPoints,
  bundle: true,
  platform: "node",
  outbase: "test-layer",
  outdir: "./.layers/test-layer",
  allowOverwrite: true,
})

スクリプトはここを参考にしました。

このぐらいのスクリプトであれば JS のままでも問題ないと思うので、このまま Node.js で実行して、ビルドします。

node ./build.js

成功しました。

2023 06 24 16 07 31

注意 ネイティブコードライブラリをレイヤーに含める必要がある場合

レイヤーの中に Windows でしか動かないコード、Linux では動かないコードがある場合は動作しないので、Amazon Linux と互換性を持たせる必要があります。

node_modules を win で作成してそのままレイヤーにデプロイしたら動かないとか。

過去にローカルの Win だと動いて Lambda だと動かないみたいなものがあった体験をした気がします。

コピーだけだとあまり意識しないと思うので、注意が必要です。

docker や WSL なんかを使うといいですかね。

Lambda Layer を AWS にデプロイ

ようやくデプロイできます。

serverless のコマンドでデプロイします。

serverless deploy

成功しました。

2023 06 24 16 44 42

念のため AWS コンソールにサインインして、確認します。

2023 06 24 16 46 43

レイヤーの動作を確認

レイヤー をテストするために、テスト用の Lambda 関数を作成しました。

ランタイムは Node.js (18.x)です。

それで呼び出して見てもモジュールが見つからないというエラー・・・

レイヤーの関数呼び出せない問題

どうやら ES モジュールはレイヤーに対応していないっぽいです。

なんというかちょっと残念。

test 関数は ESM(mjs)で作成されていたので、CJS に書き換えました。

2023 06 24 18 04 49

でようやく呼び出しができました。

2023 06 24 18 19 43

require のパスを「/opt~~」に変えています。

パスが通っているから直接指定でも大丈夫とのことだったのですが、これでは参照できませんでした。

const hoge = require("hoge")

他の参考記事も同じようにやってるけど何かが違うのでしょうか。。

これに関してはこれ以上探りませんでした。

どちらにしても opt をパスに含めない方法だと TS コンパイルの時にエラーになってしまうので、次のステップで別のパスを指定するようにします。

handler から呼び出し

レイヤーを呼び出すための関数を用意します。

テスト用なので、ハンドラからチェックします。

handler.ts
import { hoge } from '/opt/lib/hoge';
import { now } from '/opt/lib/now';

export const hello = async (event) => {
  console.log(hoge());
  console.log(now().format('YYYY-MM-DD'));
  return "OK";
};

ローカルの場合、「/opt/~~」というパスは存在しないので、tsconfig.json の paths に設定を追加しておきます。

これでエラーにならなくなります。

エディタにエラーが表示される場合は、エディタの再起動をしてみるとエラーが表示されなくなると思います。

tsconfig.json
    "paths": {
      "/opt/lib/*": ["./test-layer/lib/*"]
    }

レイヤーに存在するモジュールを外部パスとする

このままだとせっかくレイヤーを作っても、esbuild したときにレイヤーのモジュールも トランスパイル後の JS ファイルに含まれてしまいます。

というわけで、serverless-esbuild のビルド処理にプラグインを追加します。

serverless.yml
custom:
  esbuild:
    plugins: plugin.js

こちらの JS を追加し、opt を含む場合は、外部パスとして処理します。

plugin.js
let layyerExternalPlugin = {
    name: 'layer-external',
    setup(build) {
        // レイヤーに含むモジュールを外部パスとする
        build.onResolve({
            filter: /(opt)/
        }, args => {
            return {
                path: args.path,
                external: true
            }
        })
    },
};

module.exports = [layyerExternalPlugin];

これによってトランスパイルされた handler.js では、外部パスを参照するようになります。

handler.js(抜粋)
var import_hoge = require("/opt/lib/hoge");
var import_now = require("/opt/lib/now");

レイヤーの自作モジュールから参照される node_modules

レイヤーから参照されている node_modules も外部パスにします。

こちらは自力で行う esbuild のビルドスクリプトを変更します。

専用のパッケージをインストールします。

npm i -D esbuild-node-externals

ビルドスクリプトにプラグインを追加します。

build.js
// ~~省略~~

const {
    nodeExternalsPlugin
} = require('esbuild-node-externals')

build({
    entryPoints,
    bundle: true,
    platform: 'node',
    outbase: "test-layer",
    outdir: "./.layers/test-layer",
    allowOverwrite: true,
    target: "node18",
    tsconfig: "./tsconfig.json",
    plugins: [nodeExternalsPlugin()]
})

デプロイして確認

これでデプロイして確認してみます。

問題なく実行されました。

2023 06 27 01 18 05

レイヤーの最新の ARN を Ref で参照する

関数とレイヤーの紐づけは ARN を指定することで可能となっていますが、Lambda レイヤーの ARN はバージョンまで指定しなければなりません。

レイヤーを更新したらバージョンが 1 つインクリメントされていくので、レイヤーの更新の度にレイヤーを使う側のレイヤー情報も更新しなければいけないという罠があります。


ということで CloudFormation の組み込み関数「Ref」を使って最新の arn を指定するようにします。

serverless framework のリファレンスには、TitleCase にして”LambdaLayer”を結合した名前で指定せよと記載があります。

To use a layer with a function in the same service, use a CloudFormation Ref. The name of your layer in the CloudFormation template will be your layer name TitleCased (without spaces) and have LambdaLayer appended to the end. EG:

https://www.serverless.com/framework/docs/providers/aws/guide/layers#using-your-layers

リファレンスの例にある「test」というレイヤーは「TestLambdaLayer」という名前になります。

今回自分が作成したレイヤーは「test-layer」という名前なので、「TestLayerLambdaLayer」という名前になる・・・かと思ったらこれではなかったようです。

Error:
The CloudFormation template is invalid: Template format error: Unresolved resource dependencies [TestLayerLambdaLayer] in the Resources block of the template

2023 06 27 01 31 03

一旦普通に arn 指定に戻して serverless package のみ実施し、出来上がった CloudFormation のテンプレートを確認してみました。

cloudformation-template-update-stack.json
    "TestDashlayerLambdaLayer": {
      "Type": "AWS::Lambda::LayerVersion",

      ~~省略~~

    }

”-“は”Dash”になるわけですね。

省略するのはスペースだけでした。

にしても LambdaLayer と勝手にサフィックスをつけられるのなら、名前にレイヤー入れないほうがいいんだな。。。

サイズ比較

せっかくなので、サイズ比較もしておきました。

handler.ts のサイズを比較しましたが、外部パスにレイヤーにあるモジュールを指定した場合、そうでない場合と比べておよそ 1/10 になりました。

結構なサイズダウンですね。

外部パスにした場合:1,453 バイト

外部パスにしない場合:13,209 バイト

余談

このようなプラグインがありましたけど、安全かどうかわからなかったので、試していません。